Micron Document
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| SparkN0de-git | SparkN0de |
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------


Commit d8003521246b9d2f90a988a5cc37d2e21c199a2b


Parents : 51bf7c9
Author : Ivan <ivan@quad4.io>
Signature : Signature validation error
Date : 2026-04-23T04:35:17-05:00

feat(security): implement external URL normalization to prevent unsafe outbound requests in Electron

Changes
Diff

diff --git a/electron/main-legacy.js b/electron/main-legacy.js
index fe2ec687..7b145161 100644
--- a/electron/main-legacy.js
+++ b/electron/main-legacy.js
@@ -15,6 +15,7 @@ const path = require("node:path");
const crypto = require("crypto");
const { getUserProvidedArguments } = require("./mainHelpers");
const { isAllowedShellPath } = require("./shellPathGuard");
+const { normalizeExternalUrlForOpen } = require("./safeExternalUrl");
// remember main window
var mainWindow = null;
@@ -350,8 +351,10 @@ app.whenReady().then(async () => {
};
}
- // fallback to opening any other url in external browser
- shell.openExternal(url);
+ const safe = normalizeExternalUrlForOpen(url);
+ if (safe) {
+ shell.openExternal(safe);
+ }
return {
action: "deny",
};

diff --git a/electron/main.js b/electron/main.js
index 0188af80..ed265ebd 100644
--- a/electron/main.js
+++ b/electron/main.js
@@ -20,6 +20,7 @@ const path = require("node:path");
const { verifyBackendIntegrity } = require("./backendIntegrity");
const { getUserProvidedArguments, formatRenderProcessGoneDetails, isLocalBackendUrl } = require("./mainHelpers");
const { isAllowedShellPath } = require("./shellPathGuard");
+const { normalizeExternalUrlForOpen } = require("./safeExternalUrl");
// remember main window
var mainWindow = null;
@@ -372,7 +373,10 @@ function attachDefaultContextMenu(browserWindow) {
template.push({
label: "Open link",
click: () => {
- shell.openExternal(params.linkURL);
+ const safe = normalizeExternalUrlForOpen(params.linkURL);
+ if (safe) {
+ shell.openExternal(safe);
+ }
},
});
template.push({
@@ -614,8 +618,11 @@ app.whenReady().then(async () => {
};
}
- // fallback to opening any other url in external browser
- shell.openExternal(url);
+ // fallback to opening any other url in external browser (http(s) / mailto only)
+ const safe = normalizeExternalUrlForOpen(url);
+ if (safe) {
+ shell.openExternal(safe);
+ }
return {
action: "deny",
};

diff --git a/electron/safeExternalUrl.js b/electron/safeExternalUrl.js
new file mode 100644
index 00000000..28e5c256
--- /dev/null
+++ b/electron/safeExternalUrl.js
@@ -0,0 +1,35 @@
+"use strict";
+
+/**
+ * Returns a normalized http(s) or mailto URL suitable for shell.openExternal,
+ * or null if the input must not be handed to the OS shell.
+ * @param {unknown} raw
+ * @returns {string|null}
+ */
+function normalizeExternalUrlForOpen(raw) {
+ if (typeof raw !== "string") {
+ return null;
+ }
+ const s = raw.trim();
+ if (!s) {
+ return null;
+ }
+ let u;
+ try {
+ u = new URL(s);
+ } catch {
+ return null;
+ }
+ const p = u.protocol;
+ if (p === "http:" || p === "https:") {
+ return u.href;
+ }
+ if (p === "mailto:") {
+ return u.href;
+ }
+ return null;
+}
+
+module.exports = {
+ normalizeExternalUrlForOpen,
+};

diff --git a/meshchatx/src/frontend/js/LinkUtils.js b/meshchatx/src/frontend/js/LinkUtils.js
index 7836d244..e9d2264a 100644
--- a/meshchatx/src/frontend/js/LinkUtils.js
+++ b/meshchatx/src/frontend/js/LinkUtils.js
@@ -1,10 +1,29 @@
import GlobalState from "./GlobalState.js";
+import Utils from "./Utils.js";
function defaultNomadPagePath() {
const p = GlobalState.config?.nomad_default_page_path;
return typeof p === "string" && p.startsWith("/page/") ? p : "/page/index.mu";
}
+function httpUrlHrefOrNull(core) {
+ const tries = [core];
+ if (core.includes("&amp;")) {
+ tries.push(core.replace(/&amp;/g, "&"));
+ }
+ for (const candidate of tries) {
+ try {
+ const u = new URL(candidate);
+ if (u.protocol === "http:" || u.protocol === "https:") {
+ return u.href;
+ }
+ } catch {
+ /* try next */
+ }
+ }
+ return null;
+}
+
export default class LinkUtils {
static protectAnchors(text) {
const anchors = [];
@@ -75,7 +94,8 @@ export default class LinkUtils {
if (isNomadNet) {
const fullPath = path || defaultNomadPagePath();
const url = `${hash}:${fullPath}`;
- return `<a href="#" class="nomadnet-link text-blue-600 dark:text-blue-400 hover:underline font-mono" data-nomadnet-url="${url}">${match}</a>`;
+ const safeAttr = Utils.escapeHtml(url);
+ return `<a href="#" class="nomadnet-link text-blue-600 dark:text-blue-400 hover:underline font-mono" data-nomadnet-url="${safeAttr}">${match}</a>`;
} else {
// Treat as LXMF link
return `<a href="#" class="lxmf-link text-blue-600 dark:text-blue-400 hover:underline font-mono" data-lxmf-address="${hash}">${match}</a>`;
@@ -89,13 +109,18 @@ export default class LinkUtils {
static renderStandardLinks(text) {
if (!text) return "";
- const urlRegex = /(^|[^\w"'=])(https?:\/\/[^\s<]+)/g;
+ const urlRegex = /(^|[^\w"'=])(https?:\/\/[^\s<'"]+)/g;
return text.replace(urlRegex, (match, prefix, url) => {
const { core, suffix } = this.splitTrailingPunctuation(url);
if (!core) {
return match;
}
- return `${prefix}<a href="${core}" target="_blank" rel="noopener noreferrer" class="text-blue-600 dark:text-blue-400 hover:underline">${core}</a>${suffix}`;
+ const href = httpUrlHrefOrNull(core);
+ if (!href) {
+ return match;
+ }
+ const label = Utils.escapeHtml(core);
+ return `${prefix}<a href="${href}" target="_blank" rel="noopener noreferrer" class="text-blue-600 dark:text-blue-400 hover:underline">${label}</a>${suffix}`;
});
}

diff --git a/tests/backend/test_http_url_guard.py b/tests/backend/test_http_url_guard.py
index aa3dadf9..9f4ba667 100644
--- a/tests/backend/test_http_url_guard.py
+++ b/tests/backend/test_http_url_guard.py
@@ -30,3 +30,33 @@ def test_normalize_loopback_ipv6():
def test_normalize_rejects_non_loopback(bad):
with pytest.raises(UnsafeOutboundUrlError):
normalize_loopback_http_service_base(bad)
+
+
+@pytest.mark.parametrize(
+ "edge",
+ [
+ "http://127.0.0.1:5000/",
+ "http://127.0.0.1:5000",
+ "https://[::1]:8080/foo",
+ "http://localhost:3000/",
+ ],
+)
+def test_normalize_accepts_loopback_variants(edge):
+ out = normalize_loopback_http_service_base(edge)
+ assert out.startswith("http://") or out.startswith("https://")
+ assert ".." not in out
+
+
+@pytest.mark.parametrize(
+ "bad",
+ [
+ "",
+ " ",
+ "ws://127.0.0.1:1",
+ "http+unix://%2Ftmp%2Fs.sock",
+ "http://127.0.0.1%0d%0a.evil.com:80/",
+ ],
+)
+def test_normalize_rejects_scheme_or_crlf_injection(bad):
+ with pytest.raises(UnsafeOutboundUrlError):
+ normalize_loopback_http_service_base(bad)

diff --git a/tests/backend/test_repository_server_manager.py b/tests/backend/test_repository_server_manager.py
index 8b3843dd..ebafbc00 100644
--- a/tests/backend/test_repository_server_manager.py
+++ b/tests/backend/test_repository_server_manager.py
@@ -4,6 +4,8 @@ import time
import urllib.request
from unittest.mock import patch
+import pytest
+
from meshchatx.src.backend.repository_server_manager import (
RepositoryServerManager,
build_repository_index_html,
@@ -104,6 +106,23 @@ def test_save_rejects_bad_filename(tmp_path):
assert not ok
+@pytest.mark.parametrize(
+ "name",
+ [
+ "x<script>.whl",
+ "a b.whl",
+ "wheel\x00.wheel",
+ "subdir/x.whl",
+ "",
+ "bad!.whl",
+ ],
+)
+def test_save_rejects_invalid_upload_filenames(tmp_path, name):
+ mgr = RepositoryServerManager(str(tmp_path))
+ ok, err = mgr.save_upload(name, b"x")
+ assert not ok
+
+
@patch(
"meshchatx.src.backend.repository_server_manager.download_bundled_wheels_to_directory"
)

diff --git a/tests/electron/safeExternalUrl.test.js b/tests/electron/safeExternalUrl.test.js
new file mode 100644
index 00000000..d28eef91
--- /dev/null
+++ b/tests/electron/safeExternalUrl.test.js
@@ -0,0 +1,48 @@
+import { describe, it, expect } from "vitest";
+import { normalizeExternalUrlForOpen } from "../../electron/safeExternalUrl.js";
+
+describe("safeExternalUrl", () => {
+ it("allows http and https origins", () => {
+ expect(normalizeExternalUrlForOpen("http://example.com/path")).toBe("http://example.com/path");
+ expect(normalizeExternalUrlForOpen("https://example.com/x?y=1#z")).toBe(
+ "https://example.com/x?y=1#z",
+ );
+ });
+
+ it("allows mailto", () => {
+ expect(normalizeExternalUrlForOpen("mailto:test@example.com")).toBe("mailto:test@example.com");
+ });
+
+ it("rejects javascript and data URLs", () => {
+ expect(normalizeExternalUrlForOpen("javascript:alert(1)")).toBeNull();
+ expect(normalizeExternalUrlForOpen("data:text/html,<script>1</script>")).toBeNull();
+ expect(normalizeExternalUrlForOpen("file:///etc/passwd")).toBeNull();
+ expect(normalizeExternalUrlForOpen("vbscript:msgbox(1)")).toBeNull();
+ });
+
+ it("rejects non-URL garbage", () => {
+ expect(normalizeExternalUrlForOpen("")).toBeNull();
+ expect(normalizeExternalUrlForOpen(" ")).toBeNull();
+ expect(normalizeExternalUrlForOpen(null)).toBeNull();
+ expect(normalizeExternalUrlForOpen(undefined)).toBeNull();
+ expect(normalizeExternalUrlForOpen("not a url")).toBeNull();
+ });
+
+ it("allows normal http(s) for the system browser (openExternal use case)", () => {
+ expect(normalizeExternalUrlForOpen("http://192.168.0.1/")).toBe("http://192.168.0.1/");
+ expect(normalizeExternalUrlForOpen("https://example.com/path")).toBe("https://example.com/path");
+ });
+
+ it("fuzz: random strings rarely return non-null", () => {
+ for (let i = 0; i < 200; i++) {
+ const s = String.fromCodePoint(...Array.from({ length: 12 }, () => Math.floor(Math.random() * 0x80)));
+ expect(() => normalizeExternalUrlForOpen(s)).not.toThrow();
+ const o = normalizeExternalUrlForOpen(s);
+ if (o !== null) {
+ expect(o.startsWith("http://") || o.startsWith("https://") || o.startsWith("mailto:")).toBe(
+ true,
+ );
+ }
+ }
+ });
+});

diff --git a/tests/frontend/LinkUtils.test.js b/tests/frontend/LinkUtils.test.js
index 4fa23821..d7e973bf 100644
--- a/tests/frontend/LinkUtils.test.js
+++ b/tests/frontend/LinkUtils.test.js
@@ -50,7 +50,7 @@ describe("LinkUtils.js", () => {
it("detects http links", () => {
const text = "visit http://example.com";
const result = LinkUtils.renderStandardLinks(text);
- expect(result).toContain('<a href="http://example.com"');
+ expect(result).toMatch(/<a href="http:\/\/example\.com\/?"/);
});
it("detects https links", () => {
@@ -85,7 +85,7 @@ describe("LinkUtils.js", () => {
it("detects both types of links", () => {
const text = "Check https://google.com and nomadnet://1dfeb0d794963579bd21ac8f153c77a4";
const result = LinkUtils.renderAllLinks(text);
- expect(result).toContain('href="https://google.com"');
+ expect(result).toMatch(/<a href="https:\/\/google\.com\/?"/);
expect(result).toContain('data-nomadnet-url="1dfeb0d794963579bd21ac8f153c77a4:/page/index.mu"');
});
@@ -136,10 +136,20 @@ describe("LinkUtils.js", () => {
it("stops URL at space so no script in same line", () => {
const text = "https://example.com javascript:alert(1)";
const result = LinkUtils.renderStandardLinks(text);
- expect(result).toContain('href="https://example.com"');
+ expect(result).toMatch(/<a href="https:\/\/example\.com\/?"/);
expect(result).not.toMatch(/href="[^"]*javascript:/);
});
+ it("does not put attacker text inside the anchor opening tag", () => {
+ const text = 'see https://evil.example/path" onmouseover="alert(1) ok';
+ const result = LinkUtils.renderStandardLinks(text);
+ const open = result.indexOf("<a ");
+ const openEnd = result.indexOf(">", open);
+ const openTag = result.slice(open, openEnd + 1);
+ expect(openTag).not.toMatch(/onmouseover/i);
+ expect(openTag).not.toMatch(/javascript:/i);
+ });
+
it("handles null and undefined without throwing", () => {
expect(LinkUtils.renderReticulumLinks(null)).toBe("");
expect(LinkUtils.renderReticulumLinks(undefined)).toBe("");


──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────